Skip to content

菜品管理 ​

1.1 公共字段自动填充 ​

  1. 在很多表中有很多重复字段,比如创建人id,创建时间,更新人id,更新时间等,让我们的项目代码冗余,不易维护,公共字段自动填充技术就是来解决这一问题的。

    image-20260313091531057

  2. 实现思路

    • 对于create_time,create_user两个字段,是在进行插入操作时修改的,update_time,update_user两个字段是在插入和修改两个操作中修改的
    • 我们可以选择AOP的方法,通过自定义注解 @AutoFill 来指定增强范围,通过AOP的连接点参数,使用反射的方法给字段赋值
    • 为什么不用切入点表达式?因为不太好确定范围,使用正则表达式的话,需要限制方法名,不灵活
  3. 代码实现

    • 导入依赖

      java
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-aop</artifactId>
      </dependency>
    • 自定义注解

      java
      /**
       * 自定义注解,用于标识哪些方法需要增强公共字段填充处理
       */
      @Target(ElementType.METHOD) // 作用在方法上
      @Retention(RetentionPolicy.RUNTIME)
      public @interface AutoFill {
      
          /**
           * 指定数据库操作类型
           * @return
           */
          OperationType value();
      }
    • 切入点表达式

    java
    /**
     * 切入点
     */
    @Pointcut("execution(* com.sky.mapper.*(..)) && @annotation(com.sky.annotation.AutoFill)")
        public void autoFillPointcut() {}
    • 通知

      java
      /**
       * 前置通知(增强逻辑),在通知中进行公共字段的赋值
       */
      @Before("autoFillPointcut()")
      public void autoFill(JoinPoint joinPoint) {
          log.info("开始进行公共字段的填充...");
          // 1. 获取被拦截方法的@AutoFill注解的OperationType值
          MethodSignature signature = (MethodSignature) joinPoint.getSignature();// 获得签名对象
          AutoFill annotation = signature.getMethod().getAnnotation(AutoFill.class);// 获得方法上的注解对象
          OperationType value = annotation.value();// 获得数据库操作类型
          // 2. 获取到被拦截方法的参数(需要被赋值的实体类)
          // 这里做一个约定,要想实现自动填充的话,将被增强参数放在第一个位置
          Object[] args = joinPoint.getArgs();
          if (args == null || args.length == 0) {
              log.debug("AOP增强方法参数有问题!{}",args);
              return;
          }
          Object entity = args[0];
          // 3. 准备赋值的数据
          LocalDateTime now = LocalDateTime.now();
          Long currentId = BaseContext.getCurrentId();
          // 4. 根据OperationType值不同,选择不同的增强逻辑,为实体类通过反射赋值
          if(value == OperationType.INSERT){
              // 为四个字段赋值
              try {
                  Method setCreateTime = entity.getClass().getMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
                  Method setUpdateTime = entity.getClass().getMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                  Method setCreateUser = entity.getClass().getMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
                  Method setUpdateUser = entity.getClass().getMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
      
                  setUpdateUser.invoke(entity, currentId);
                  setCreateUser.invoke(entity, currentId);
                  setUpdateTime.invoke(entity, now);
                  setCreateTime.invoke(entity, now);
      
              } catch (Exception e) {
                  throw new RuntimeException(e);
              }
      
          } else if (value == OperationType.UPDATE) {
              // 为两个字段赋值
              try {
                  Method setUpdateTime = entity.getClass().getMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                  Method setUpdateUser = entity.getClass().getMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
      
                  setUpdateUser.invoke(entity, currentId);
                  setUpdateTime.invoke(entity, now);
      
              } catch (Exception e) {
                  throw new RuntimeException(e);
              }
          }
      }

1.2 新增菜品 ​

1.2.1 需求分析和设计 ​

  • 产品原型

    image-20260313160233971

  • 业务规则

    • 菜品名称唯一
    • 菜品必须属于某个分类下,不能单独存在(外键约束以及逻辑外键约束(推荐)、删除分类时需要先将菜品删除完)
    • 新增菜品可以根据情况选择菜品的口味
    • 每个菜品必须对应一张图片

1.2.2 代码实现之-上传文件(阿里云OSS) ​

  • 添加依赖

    xml
    <dependency>
        <groupId>com.aliyun.oss</groupId>
        <artifactId>aliyun-sdk-oss</artifactId>
        <version>3.17.4</version>
    </dependency>

    如果使用的是Java 9及以上的版本,则需要添加以下JAXB相关依赖。

    xml
    <dependency>
        <groupId>javax.xml.bind</groupId>
        <artifactId>jaxb-api</artifactId>
        <version>2.3.1</version>
    </dependency>
    <dependency>
        <groupId>javax.activation</groupId>
        <artifactId>activation</artifactId>
        <version>1.1.1</version>
    </dependency>
    <!-- no more than 2.3.3-->
    <dependency>
        <groupId>org.glassfish.jaxb</groupId>
        <artifactId>jaxb-runtime</artifactId>
        <version>2.3.3</version>
    </dependency>
  • 定义配置属性类,这样该属性类可以直接拿到配置文件中的属性

    java
    @Component
    @ConfigurationProperties(prefix = "sky.alioss")
    @Data
    public class AliOssProperties {
    
        private String endpoint;
        private String accessKeyId;
        private String accessKeySecret;
        private String bucketName;
    
    }
  • 定义OSS工具类

    java
    @Data
    @AllArgsConstructor
    @Slf4j
    public class AliOssUtil {
    
        private String endpoint;
        private String accessKeyId;
        private String accessKeySecret;
        private String bucketName;
    
        /**
         * 文件上传
         *
         * @param bytes
         * @param objectName
         * @return
         */
        public String upload(byte[] bytes, String objectName) {
    
            // 创建OSSClient实例。
            OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
    
            try {
                // 创建PutObject请求。
                ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
            } catch (OSSException oe) {
                System.out.println("Caught an OSSException, which means your request made it to OSS, "
                        + "but was rejected with an error response for some reason.");
                System.out.println("Error Message:" + oe.getErrorMessage());
                System.out.println("Error Code:" + oe.getErrorCode());
                System.out.println("Request ID:" + oe.getRequestId());
                System.out.println("Host ID:" + oe.getHostId());
            } catch (ClientException ce) {
                System.out.println("Caught an ClientException, which means the client encountered "
                        + "a serious internal problem while trying to communicate with OSS, "
                        + "such as not being able to access the network.");
                System.out.println("Error Message:" + ce.getMessage());
            } finally {
                if (ossClient != null) {
                    ossClient.shutdown();
                }
            }
    
            //文件访问路径规则 https://BucketName.Endpoint/ObjectName
            StringBuilder stringBuilder = new StringBuilder("https://");
            stringBuilder
                    .append(bucketName)
                    .append(".")
                    .append(endpoint)
                    .append("/")
                    .append(objectName);
    
            log.info("文件上传到:{}", stringBuilder.toString());
    
            return stringBuilder.toString();
        }
    }
  • 定义配置类

    java
    /**
     * 创建一个AliOssUtil类对象交给SpringIOC容器管理
     */
    @Slf4j
    @Configuration
    public class OssConfiguration {
    
        @Bean
        @ConditionalOnMissingBean
        public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties) {
            return new AliOssUtil(aliOssProperties.getEndpoint(),
                    aliOssProperties.getAccessKeyId(),
                    aliOssProperties.getAccessKeySecret(),
                    aliOssProperties.getBucketName());
        }
    
    }

    @ConditionalOnMissingBean,对象是单例模式,容器中同一时刻只能存在一个对象。

    注意:在后面的开发中遇到了问题,我修改了AliyunOSSUtils类的endpoint属性,导致我连续上传文件时,解析路径时数组越界,所以对于这种由配置类声明的工具类,不要随便改属性,会影响容器中的那个对象,是全局生效的。

  • 写Controller

    java
        /**
         * 利用阿里云OSS服务完成文件的上传
         * @param file
         * @return
         */
        @PostMapping("/upload")
        public Result<String> upload(@RequestParam("file") MultipartFile file) {
            log.info("文件上传:{}", file.getOriginalFilename());
            // 在Bucket上的储存路径
            // 目录
            String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
            // 文件名 : UUID + 文件后缀名
            String newFileName = UUID.randomUUID() + file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."));
            String objectName = dir + "/" + newFileName;
            try {
                String upload = aliOssUtil.upload(file.getBytes(), objectName);
                return Result.success(upload);
            } catch (IOException e) {
                log.error("文件上传失败,{}",e);
            }
            return Result.error("文件上传失败!");
        }
    }
  • 需要在yml配置文件中写入参数

    yml
    sky:
      alioss:
        endpoint: ${sky.alioss.endpoint}
        bucket-name: ${sky.alioss.bucket-name}
        access-key-id: ${sky.alioss.access-key-id}
        access-key-secret: ${sky.alioss.access-key-secret}

    这里使用了引用的方式赋值,在不同的环境下可以配置不同的OSS服务

1.2.3 主键回填 ​

在 MyBatis 中,主键回填(Generated Keys) 是指在执行 INSERT 语句后,由数据库自动生成的自增主键(如 MySQL 的 AUTO_INCREMENT)被自动赋值回到 Java 实体对象的过程。

这在你的“苍穹外卖”项目中至关重要,因为你需要先插入菜品(Dish),拿到生成的 ID 后,才能给口味表(DishFlavor)设置关联的 dish_id。


  1. 如何在 XML 中开启主键回填?

你需要在 <insert> 标签中添加两个关键属性:

  • useGeneratedKeys="true":告诉 MyBatis 使用数据库内置的生成机制获取主键。
  • keyProperty="id":指定将获取到的主键值赋值给传入对象的哪一个属性(通常是实体类中的 id 字段)。

XML 配置示例:

xml
<insert id="save" useGeneratedKeys="true" keyProperty="id">
    insert into dish (name, category_id, price, image, description, status, create_time, update_time, create_user, update_user)
    values (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})
</insert>

  1. 在 Java 代码中如何获取?

执行完 Mapper 方法后,你不需要去调用类似 getGeneratedId 的方法。MyBatis 会直接修改你传入的那个对象。

java
@Override
@Transactional
public void saveWithFlavor(DishDTO dishDTO) {
    Dish dish = new Dish();
    BeanUtils.copyProperties(dishDTO, dish);

    // 1. 执行插入操作
    // 此时 dish.getId() 为 null
    dishMapper.save(dish);

    // 2. 核心时刻:执行完上面那行后,MyBatis 已经把 ID 填进去了
    Long newDishId = dish.getId();
    log.info("生成的新菜品 ID 为:{}", newDishId);

    // 3. 后续业务:给口味关联这个 ID
    List<DishFlavor> flavors = dishDTO.getFlavors();
    if (flavors != null && !flavors.isEmpty()) {
        flavors.forEach(f -> f.setDishId(newDishId));
        dishFlavorMapper.insertBatch(flavors);
    }
}

  1. 注意事项与常见错误

  2. 返回值的误解:

    Mapper 方法的返回值 int 依然是受影响的行数(通常是 1),而不是主键 ID。ID 是通过对象的 getter 方法拿到的。

  3. XML 属性拼写:

    keyProperty 必须对应实体类中的属性名(如 id),而不是数据库中的列名(如 id)。如果属性名不匹配,回填会失败且不会报错,只是 getId() 拿不到值。

  4. 批量插入的回填:

    如果你在做口味表的批量插入(insertBatch),同样可以使用这两个属性。MyBatis 会尝试将生成的一组 ID 对应填入 List 中的每一个实体对象中。

  5. SQL 语句:

    不要在 XML 的 SQL 语句里写 id 字段及其值。主键字段应该交给数据库自动维护。


💡 生产环境小技巧

在调试时,如果发现 dish.getId() 拿到的是 0 或 null,请第一时间检查两个地方:

  • 数据库表结构:id 字段是否勾选了 AUTO_INCREMENT。
  • MyBatis XML:是否漏写了 useGeneratedKeys="true"。

1.3 菜品分页查询 ​

  1. 传递数据采用了DishPageQueryDTO,返回数据时使用了DishVO,涉及到了菜品表和分类表的多表查询。
  2. 在业务上,因为查询的是菜品,不管菜品符不符合连接条件,都要返回,采用了左外连接

1.4 删除菜品 ​

  1. 当该菜品处于在售状态,不允许删除

  2. 可以一次删除一个或多个菜品

  3. 删除菜品后,关联的口味数据也要删除

  4. 被套餐关联的菜品不能删除,需要先在套餐中移除菜品,才可删除

  5. 接口设计成一次删除多个菜品的接口,把参数放进集合中传递过来,遍历集合,删除菜品及关联的数据,需要开启事务

  6. 接口:DELETE /admin/dish/ids=1,2,3

  7. 参数类型:String,可以直接使用SpringMVC的@RequestParam注解,自动将参数映射成List<Long>

  8. 实际开发中遇到的问题:

    • 错误考虑了在Java代码中使用遍历一条一条的删除数据,没有想到SQL的批量删除 &lt;foreach&gt;+ in()
  9. MyBatis <foreach> 标签的使用

    • 案例:

      xml
      <delete id="deleteByIds">
          delete from dish where id in
          <foreach collection="ids" item="id" separator="," open="(" close=")">
              #{id}
          </foreach>
      </delete>
      sql
      批量删除 DELETE FROM dish WHERE id IN (1, 2, 3)
      xml
      <insert id="insertBatch">
          insert into dish_flavor (dish_id, name, value) values
          <foreach collection="flavors" item="f" separator=",">
              (#{f.dishId}, #{f.name}, #{f.value})
          </foreach>
      </insert>
      sql
      批量插入 INSERT INTO dish_flavor (...) VALUES (id, name, val), (id, name, val)...

1.5 修改菜品 ​

  1. 需要查询回显,根据ID查询菜品接口,查询菜品分类接口(已实现),图片上传接口(已实现),修改菜品,四个接口

  2. 查询菜品回显时,封装成DishVO对象返回,其中的flavors字段是个集合,需要连表查询再映射,这里如何解决这个映射的问题?

    这里视频中的方法是分步查询,先查除口味之外的数据,用到 dish 和 category 两个表进行连表查询;之后再查菜品的口味数据,最后将所有的数据封装到一个DishVO对象中返回

  3. 修改菜品涉及到dish表,dish_flavor表,setmeal_dish表,需要事务

  4. 对于dish_flavor表,由于数据比较复杂,细致调整很麻烦,所以我们拆分为两步操作:删掉原数据,再添加新数据。在添加新数据的时候,我们需要获取菜品的ID,前端并没有将该ID传过来。

  5. 对于setmeal_dish表,我们选择同步更新,保持数据一致性,对于这种表,是根据实际业务来决定如何处理的